Skip to content

feat(rules-ui): metadata conditions + actions in the rule form editor#1876

Merged
canalesb93 merged 1 commit into
mainfrom
feat/rules-metadata-ui
Jun 17, 2026
Merged

feat(rules-ui): metadata conditions + actions in the rule form editor#1876
canalesb93 merged 1 commit into
mainfrom
feat/rules-metadata-ui

Conversation

@canalesb93

Copy link
Copy Markdown
Owner

Stacked on #1874 (backend). The visual rule builder gains first-class metadata support so humans can author the new metadata.<key> conditions and set_metadata / remove_metadata actions in the form — no dropping to the JSON editor (and no longer API/MCP-only).

Rule form with metadata conditions and actions

Conditions

  • New "Metadata field" option in the field dropdown reveals a key input and a dedicated operator select: equals not equals contains not contains regex in list > ≥ < ≤ exists does not exist.
  • The value input hides for the presence ops (exists / does not exist) and comma-splits for in list.
  • The dotted metadata.<key> field is assembled at submit time and split back into field + key when loading a rule for edit.

Actions

  • Set metadata — key + a text / number / true/false type selector + value. The value is coerced to the chosen JSON type on submit ("2026"2026, "true"true), so the stored blob is correctly typed.
  • Remove metadata — key only.
  • Both repeat freely; a same-key set+remove raises the existing combination warning.

Why a special case

The builder's {field, op, value} condition and {field, value} action models have no slot for a dynamic key or a typed value — metadata rows carry extra key / valueType fields, with dedicated serialize/parse paths.

Validation (real browser)

  • Forward serialization captured from submitRule(): metadata.tax_deductible eq "true", metadata.project_code exists (no value), metadata.tags_csv in ["a","b","c"]; actions with typed values (string / number 2026 / boolean true) + remove_metadata.
  • Create → reload /rules/{id}/edit → state re-populates correctly (field=metadata + key + op + value; action valueType recovered as number/boolean/text) → delete. Backend accepted the POST (201) and DELETE (204).
  • go build, go vet, and the pages/scripts-lint tests pass.

🤖 Generated with Claude Code

@canalesb93 canalesb93 added the stacked Part of a Graphite stacked PR (filter-friendly) label Jun 16, 2026
@canalesb93 canalesb93 force-pushed the feat/rules-metadata branch from af4f33b to c25ac58 Compare June 17, 2026 01:53
@canalesb93 canalesb93 force-pushed the feat/rules-metadata-ui branch from 1ade324 to 63f3301 Compare June 17, 2026 01:53
@canalesb93

Copy link
Copy Markdown
Owner Author

Pushed a fix from code review: a metadata Set metadata action with type number and a non-numeric value now surfaces an inline error and blocks save, instead of silently storing the raw string (which contradicted the chosen type). Rebased onto the updated backend branch.

Base automatically changed from feat/rules-metadata to main June 17, 2026 01:56
Adds first-class metadata support to the visual rule builder, so humans
can author the metadata.<key> conditions and set_metadata /
remove_metadata actions shipped in the backend PR without dropping to
the JSON editor.

Conditions: a new "Metadata field" option reveals a key input and a
dedicated operator select (eq/neq/contains/not_contains/matches/in/
gt/gte/lt/lte/exists/not_exists). The value input hides for the
presence ops (exists / does not exist) and comma-splits for `in`. The
dotted field metadata.<key> is assembled at submit time and split back
out when loading a rule for edit.

Actions: "Set metadata" (key + a text/number/true-false type selector +
value, coerced to the chosen JSON type on submit) and "Remove metadata"
(key only). Both repeat freely; a same-key set+remove raises the
existing combination warning.

Special-cased because the builder's {field,op,value} condition and
{field,value} action models have no slot for a dynamic key or a typed
value — metadata rows carry extra key / valueType fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@canalesb93 canalesb93 force-pushed the feat/rules-metadata-ui branch from 63f3301 to c69bdfa Compare June 17, 2026 01:57
@canalesb93 canalesb93 merged commit 468d470 into main Jun 17, 2026
8 of 16 checks passed
@canalesb93 canalesb93 deleted the feat/rules-metadata-ui branch June 17, 2026 02:01
canalesb93 added a commit that referenced this pull request Jun 20, 2026
…(P1) (#1882)

Adds the condition expressiveness that lets rules absorb recurrence
without a shipped detector (rules-as-substrate P1, roadmap §4a):

- Numeric `approx` (value ± tolerance) and `between` (min/max) operators.
- Date-part fields derived from the tz-naive `date` column (no timezone
  math): day_of_month, month, day_of_week, day_of_year — all numeric.
- `day_of_month approx` is cyclic (1 ↔ the month's last day are 1 apart)
  and clamps a target past a short month to its last day, so "the 31st"
  matches Feb. Boundary-tested across Feb / 30- / 31-day / leap months.

Threaded end-to-end (mirroring the metadata-condition pattern from #1874/
#1876): service validation + compile/eval, sync resolver + the four
retroactive/preview context queries, engine date seeding, MCP tool
schemas, the admin rule-form builder (field optgroup + ≈/between inputs),
and docs/rule-dsl.md + docs/mcp-tools-reference.md. openapi.yaml needs no
change (conditions are additionalProperties; no new routes — drift green).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
canalesb93 added a commit that referenced this pull request Jun 24, 2026
… migrations (#1906)

* docs: adopt rules-as-substrate doctrine in CLAUDE.md (#1879)

Add the "Operating Model — the reconciliation flywheel" section codifying the
holy rule (provider data is immutable; breadbox intelligence accrues as rules)
and its four principles: surrogate identity, rules-as-only-deterministic-layer,
agents-review / rules-remember, and no shipped resolution. Provenance precedence
is explicitly deferred; detectors/normalizers-as-identity are out.

First step (P0) of the rules-as-universal-substrate adoption.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs: rule-matchable-field stability contract + doctrine pointers (P0-T6/T7) (#1880)

P0-T6: add the authoritative match-stability contract to docs/rule-dsl.md —
every condition field tagged raw-immutable / stable-surrogate / mutable-display,
derived from validConditionFields (internal/service/rules.go:30-46) and the
TransactionContext evaluation in internal/sync/rule_resolver.go:775-810. Guidance:
author on raw-immutable + stable-surrogate; mutable-display fields silently break
on rename or depend on pipeline order.

P0-T7: add "Governing doctrine" pointers near the top of docs/rule-dsl.md and
docs/data-model.md referencing the CLAUDE.md "Operating Model — the reconciliation
flywheel" section and the Obsidian planned-features docs.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rules): retroactive apply parity for all state-mutating actions (P0-T4) (#1881)

The bulk ApplyAllRulesRetroactively path dropped assign_series (and would
silently drop any newly-added action) because its per-txn action switch had
no case for it. The single-rule ApplyRuleRetroactively path handled the full
set, so retroactive coverage diverged by entry point.

Extract a single shared per-transaction materializer, applyRetroTxnIntent,
that both retroactive paths reduce their matches to and call. It covers every
state-mutating action — set_category (override-guarded), add_tag, remove_tag,
assign_series, set_metadata, remove_metadata — so coverage can no longer
diverge, and the P1 set_field family has one place to land.

- ApplyAllRulesRetroactively: add the assign_series case (the load-bearing
  fix) + an explicit, documented add_comment no-op; route materialization
  through applyRetroTxnIntent.
- ApplyRuleRetroactively: route its batch materialization through the same
  applier.
- actionAuditFields: add the missing remove_tag case (audit field
  "tag_remove", mirroring the sync resolver) so rule_applied annotations are
  consistent with add_tag.
- add_comment is now consistently skipped on retroactive apply across all
  paths, documented on retroTxnIntent (it is sync-time narration, not durable
  state replayed on a historical backfill).

The category_override='none' guard is preserved unchanged (its removal is P3).

Adds an integration regression test asserting each state-mutating action
(especially assign_series) materializes through the bulk apply-all path.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(rules): richer conditions — amount tolerance + date-part fields (P1) (#1882)

Adds the condition expressiveness that lets rules absorb recurrence
without a shipped detector (rules-as-substrate P1, roadmap §4a):

- Numeric `approx` (value ± tolerance) and `between` (min/max) operators.
- Date-part fields derived from the tz-naive `date` column (no timezone
  math): day_of_month, month, day_of_week, day_of_year — all numeric.
- `day_of_month approx` is cyclic (1 ↔ the month's last day are 1 apart)
  and clamps a target past a short month to its last day, so "the 31st"
  matches Feb. Boundary-tested across Feb / 30- / 31-day / leap months.

Threaded end-to-end (mirroring the metadata-condition pattern from #1874/
#1876): service validation + compile/eval, sync resolver + the four
retroactive/preview context queries, engine date seeding, MCP tool
schemas, the admin rule-form builder (field optgroup + ≈/between inputs),
and docs/rule-dsl.md + docs/mcp-tools-reference.md. openapi.yaml needs no
change (conditions are additionalProperties; no new routes — drift green).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(rules): flag / unflag actions across sync, retroactive, MCP + UI (P1) (#1883)

Add two no-parameter rule actions that surface transactions for human
attention, mirroring the flag_transaction MCP tool's flagged_at write:

- flag   → sets transactions.flagged_at = NOW()
- unflag → clears flagged_at to NULL

Threaded through every layer so rule-driven flags are consistent with
tool-driven flags and both retroactive paths inherit it for free:

- Validation: validActionTypes + ValidateActions (no params; last-writer-wins).
- Sync: typedAction parse, RuleActions.FlagIntent (tri-state, last-writer-wins,
  dropFlagSource for audit), engine.applyRulesToTransaction materializes
  flagged_at within the sync tx.
- Retroactive: retroTxnIntent.flagIntent + the shared applyRetroTxnIntent, so
  both ApplyRuleRetroactively and the bulk ApplyAllRulesRetroactively cover it
  with no per-path divergence; actionAuditFields emits flag/unflag rule_applied.
- MCP: action jsonschema descriptions (create/update/batch + apply_rules).
  No parser change needed — convertMCPActions passes type through.
- Admin UI: rule_form.js action registry + serializer (value-less), singleton
  guard + flag/unflag combination warning; rule_form.templ adds the two
  options and a hint row (no value input). ActionsSummary labels them.
- Docs: rule-dsl.md action sections + retroactive table; mcp-tools-reference.md.

Tests: unit (validate + resolve last-writer-wins + parse + audit fields);
integration (flag/unflag via sync AND both retroactive entry points).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(series): remove the detection surface (P2-1) (#1884)

Rules-as-universal-substrate, P2 step 1: delete the shipped recurring-series
detector/normalizer. Series membership now comes only from assign_series rules
(materialized via AssignSeriesInTx, still wired). No schema change this PR — the
transactions.merchant_key column stays (now NULL for new rows) and the
assign_series merchant_key path is untouched; both are reshaped in P2-PR2.

Deleted: internal/service/series_detect.go, series_detector.go,
internal/sync/merchantkey.go, internal/cli/series.go and their tests.

Removed the detection-coupled read surface that depended on the deleted
cadenceIntervalDays helper: seriesRenewalHealth + SeriesHealth* consts,
RenewalHealth/DaysUntilRenewal on SeriesResponse, ReinferSeriesTypes, and the
explain_series_candidates MCP tool + GET /series/explain REST route (their
service impl ExplainSeriesCandidates lived in the deleted detector).

Callers updated to stay green: cli/serve.go (drop detection hook, keep
AssignSeriesInTx), cli/root_full.go (drop AddSeriesCmd), sync/engine.go
(MerchantKey -> pgtype.Text{}), admin/subscriptions.go (drop renewal chip),
service/fields_entities.go, mcp + api registrations, openapi.yaml + docs.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(series): thin rule-maintained schema + surrogate-first assign_series (P2-2) (#1885)

Rebuild recurring_series as a thin, rule-maintained entity (rules-as-universal
substrate, P2). A series is now just a surrogate identity (id/short_id), an
agent/user-authored name, and a type — membership comes only from assign_series
rules, with no shipped detector, no cadence/amount/next-date stats, and no
confidence/lifecycle axis.

Migration (destructive, authorized): drop the detector app_config seeds, drop
transactions.merchant_key, DROP TABLE recurring_series CASCADE, recreate thin
(id, short_id, name UNIQUE-where-live, type, timestamps), re-point series_tags +
transactions.series_id FKs.

assign_series goes surrogate-first: target by series_short_id or mint/resolve by
series_name (idempotent UPSERT on the unique live name). RuleAction renames
MerchantKey→SeriesName with a backward-compat parse for stored merchant_key rules
(both the service JSON path and the sync resolver). Service rebuild drops the
whole detection funnel (UpsertSeriesCandidate, ReviewSeries, RekeySeries,
SplitSeries, SetSeriesType, rollups, source/confidence/cadence helpers) and adds
ListGoverningRules. MCP/REST drop review/rekey/split/type; assign/update reshaped.
openapi + docs (mcp-tools-reference, api-endpoints, data-model) updated.

admin/subscriptions.go is at a minimal compiling state; the detection-free UI
rebuild (linked-charges + governing-rules panels) lands in P2-PR3.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(series): detection-free admin UI with governing-rules view (P2-3) (#1886)

Rebuild the recurring-series admin surface for the rules-as-substrate model.
A series is a thin entity (name + type) whose membership comes only from
`assign_series` rules, so the detail page now makes that explicit: linked
charges sit beside the GOVERNING RULES that define them.

- /recurring list: flat ledger of every live series (name · type · charge
  count); no candidate/review queue, no detection/confidence/cadence/renewal.
- /recurring/{id} detail: Linked charges + Governing rules panels (via
  svc.ListGoverningRules), each rule showing its condition summary + a link to
  the rule editor; thin name/type edit drawer; inline tags.
- New series form: name + type only.
- components: drop the detector panels (SeriesDetectionPanel/MatchWindow/
  EvidenceTimeline/FactStrip) + Renewal/Confidence chips; add GoverningRulesPanel
  (keep SeriesTypeChip).
- service/db: add CountSeriesMembersGrouped query + ListSeriesMemberCounts so the
  list shows per-series charge counts in one round-trip.
- JS: strip candidate-review/detection/verdict/rekey/category logic; keep the
  thin name/type save, tags, and link/unlink-charge paths.
- design sandbox + docs (data-model, rule-dsl): note that a series' membership is
  defined by its assign_series rules and surfaced via the governing-rules view.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(rules): drop category_override / provenance guard; preserve category on re-sync (P3) (#1887)

P3 of the rules-as-universal-substrate sprint. Provenance/precedence
(user > agent > rule) is deferred: drop the `category_override` column and
its guard entirely. Rules, agents, and users all write `category_id`
directly (last-writer-wins). The sync engine still only runs rules on
`isNew||isChanged` transactions, so a user's manual edit on an unchanged
row is not continuously re-clobbered. Annotations remain an audit log; no
logic keys off them.

- migration: DROP COLUMN category_override + its CHECK constraint
- UpsertTransaction: on-conflict preserves existing category_id (raw
  re-sync never changes category — resolves the deferred P0-T5 concern)
- collapse SetTransactionCategoryOverride{,Agent} into SetTransactionCategory;
  delete the lock-toggle query/service/handler/route + detail-page UI
- remove override guards from sync engine, retroactive apply, preview,
  bulk recategorize, and the update_transactions agent/user branch
- update_transactions: Status is now ok/error only (no "skipped"); summary
  drops the skipped count
- strip category_override from MCP/REST responses, schemas, openapi, docs,
  and agent prompts
- new docs/transaction-columns.md: the canonical transaction-column contract
  (class + rule-matchability per column, + naming reassessment for review)
- tests: delete the override enum/precedence suites; invert the sacred-lock
  tests to last-writer-wins; add a raw-re-sync-preserves-category guard


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agents): Transaction Reviewer preset + rules curriculum (P5-1) (#1888)

Add the Transaction Reviewer workflow preset and the two prompt blocks
that teach agents the rules-as-universal-substrate operating model:
agents review each transaction and either make a one-off edit or codify a
recurring pattern as a rule, so the next sync resolves it automatically.

- prompts/agents/rules-curriculum.md — knowledge block. The DECISION
  framework: codify-vs-one-off heuristic, the find_matching_rules →
  preview_rule → create/batch_create_rules author flow, the stable-field
  doctrine (raw immutable fields + date-parts, never mutable display
  fields), the recurrence idiom (amount approx + day_of_month approx →
  assign_series), and provenance-free last-writer-wins writes. References
  breadbox://rule-dsl for the grammar rather than duplicating it.
- prompts/agents/strategy-transaction-reviewer.md — strategy block. The
  per-transaction question checklist (uncategorized? recurring? known
  entity?) and the one-off-vs-rule decision.
- internal/service/workflow_presets.go — one "transaction-reviewer"
  preset entry (Categorization & Review, read_write, post-sync,
  apply-mode option). Passes all TestT1* preset unit tests.

No migration, no schema, no openapi, no UI changes.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs(mcp): bring breadbox://rule-dsl resource to grammar parity (P1/P2 capabilities) (#1889)

The agent-facing breadbox://rule-dsl MCP resource was still the P0 version
while the rules-as-substrate sprint shipped P1/P2 grammar. Agents read this
resource to author rules, so the stale grammar undermined the doctrine.

Brought to full parity with internal/service/rules.go and
internal/sync/rule_resolver.go:
- date-part fields (day_of_month, month, day_of_week, day_of_year)
- numeric approx (value + tolerance) and between (min/max) operators
- the recurrence idiom (amount approx + day_of_month approx -> assign_series)
- surrogate-first assign_series (series_short_id | series_name+create_if_missing;
  no merchant_key), plus set_metadata/remove_metadata/flag/unflag actions
- provenance-free last-writer-wins semantics (category_override removed in P3)
- stable-vs-mutable field guidance; removed detector / merchant_key /
  category_override references

assign_counterparty noted as not-yet-available (P4).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(counterparties): entity + assign_counterparty action + resolution (P4-A) (#1890)

Introduce the counterparty dimension (rules-as-universal-substrate, P4 PR A):
a canonical, cross-provider, surrogate-identity entity that is the "other
side" of a transaction (merchants AND non-merchants), resolved PURELY by
assign_counterparty rules on raw provider fields — no normalizer, no
auto-create (unless create_if_missing), no UNIQUE on name.

- Migrations: counterparties table (id/short_id+trigger, name, enrichment
  cols, category_id FK, attrs JSONB, canonical self-ref, partial indexes, NO
  unique-on-name) + transactions.counterparty_id (ON DELETE SET NULL +
  partial index); annotations_kind_check widened with
  counterparty_assigned/_unlinked. UpsertTransaction unchanged.
- sqlc queries/counterparties.sql + service/counterparties.go mirroring the
  series surrogate path (AssignCounterparty imperative, resolve-or-create by
  name, AssignCounterpartyFromRuleTx engine hook, List/Get/Update/Delete,
  ListCounterpartyGoverningRules, link/unlink + membership annotations).
- assign_counterparty action wired at every assign_series site: types.go
  (RuleAction fields, CounterpartyResponse/AssignCounterpartyInput),
  rules.go (validActionTypes, ValidateActions, actionAuditFields,
  retroTxnIntent + applyRetroTxnIntent, both retroactive paths, condition
  fields counterparty/has_counterparty + service evaluateLeaf + context
  queries), sync/rule_resolver.go (typedAction, parseTypedActions,
  CounterpartyAssignIntent, RuleActions, ResolveWithContext last-writer-wins
  + dropCounterpartySource, cpShortID cache, evaluateLeaf), sync/engine.go
  (AssignCounterpartyInTx hook + materialization + tctx seeding),
  cli/serve.go (wire the hook).
- Tests: counterparties_integration_test.go (assign by short_id, create-by-
  name, retroactive single + bulk, governing rules, cascade-delete nulls,
  condition matching, ValidateActions) + rule_resolver_counterparty_test.go
  (parseTypedActions, ResolveWithContext last-writer-wins, evaluateLeaf).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* test(agents): reviewer-authors-rule E2E (P5-2) (#1891)

Prove the rules-as-substrate doctrine's core loop end-to-end at the
service layer: a scheduled reviewer authors a rule, the next sync
applies it retroactively, and matching transactions resolve to the
right surrogate entity. No fake sidecar — the load-bearing pipeline is
rule → apply → membership, exercised through the real
CreateTransactionRule + ApplyAllRulesRetroactively methods.

- TestReviewerAuthorsRecurrenceRule: provider_name contains + amount
  approx (P1 operator) → assign_series(create_if_missing) → series
  minted, all members linked.
- TestReviewerAuthorsCounterpartyRule: provider_name contains →
  assign_counterparty(create_if_missing) → counterparty resolved,
  members bound (P4 action through the reviewer loop).
- TestReviewerAuthorsDayOfMonthRule: day_of_month approx D±N recurrence
  idiom links only the in-window charges.
- MCP companion: create_transaction_rule + assign_series + retroactive
  apply response-shape + side-effect assertion.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(counterparties): read-model join + display (P4-B) (#1892)

Surface the assigned counterparty as the primary display for a transaction
across every read path, with the on-the-fly COALESCE(provider_merchant_name,
provider_name) as fallback.

- service/transactions.go: LEFT JOIN counterparties in ListTransactions,
  ListTransactionsAdmin, GetAdminTransactionRowsByIDs, GetTransaction;
  select cp.short_id/name/logo_url.
- service/types.go: CounterpartyShortID/Name/LogoURL on TransactionResponse
  and AdminTransactionRow; CounterpartyShortID/LogoURL on MerchantSummaryRow.
- service/transaction_summary.go: merchant rollup is counterparty-aware —
  SELECT COALESCE(cp.name, ...), LEFT JOIN cp, GROUP BY counterparty_id + cp keys.
- service/fields.go: counterparty_short_id/counterparty_name in the field whitelist.
- Display fallback (prefer counterparty name; 16x16 logo when present): tx_row
  list, transaction_detail hero + details, feed cards, CSV export merchant column.
- openapi.yaml: document the new optional/nullable Transaction fields.
- Integration tests: join surfaces counterparty name on list/detail; merchant
  rollup collapses raw provider names under one counterparty group.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs(agents): add assign_counterparty to the rule grammar + curriculum (#1893)

The assign_counterparty action and counterparty/has_counterparty
condition fields now exist in the engine (P4-PR-A), but the two
agent-facing grammar docs still omitted them. Bring both to parity:

- prompts/mcp/rule-dsl.md (breadbox://rule-dsl): add assign_counterparty
  action examples + semantics, the counterparty/has_counterparty
  membership fields (marked derived/mutable), and counterparty to the
  stable-vs-mutable guidance. Replace the "not available yet" note.
- prompts/agents/rules-curriculum.md: add assign_counterparty to the
  action catalog, list counterparty/has_counterparty as mutable, and
  replace the deferred-follow-up HTML comment with the counterparties
  idiom (identify the entity → author a rule on raw fields → reuse
  across providers rather than duplicating).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(counterparties): MCP + REST + admin pages + enrichment (P4-C) (#1894)

The surface layer over the P4 counterparty service (entity + action +
read-model merged in PR A/B). Counterparties are the canonical,
cross-provider "other side" of a charge — merchants and non-merchants —
whose membership comes from assign_counterparty rules.

MCP (internal/mcp/tools_counterparties.go + server.go):
  list_counterparties, get_counterparty (read); create_counterparty,
  update_counterparty (enrichment), assign_counterparty (one-off; nudges
  authoring a RULE for durability), unlink_counterparty_transaction
  (write). Resource template breadbox://counterparty/{short_id} returns
  detail + governing rules.

REST (internal/api/counterparties.go + router.go):
  GET /counterparties, GET /{id} (read); POST, PATCH /{id}, POST
  /{id}/transactions, DELETE /{id}/transactions/{txid} (full_access).
  openapi.yaml: Counterparty schema + all routes + tag.

Admin (internal/admin/counterparties.go + templates):
  /counterparties list (name · logo · linked-charge count ·
  governing-rule count) and /counterparties/{id} detail (manual
  enrichment form, linked charges, GoverningRulesPanel reused from the
  series detail). Nav + command-palette entry under Manage.

Service helpers: CounterpartyMembers, ListCounterpartyMemberCounts,
ListCounterpartyGoverningRuleCounts (+ two sqlc queries).

Docs: mcp-tools-reference.md, api-endpoints.md.

build + vet + test green; TestOpenAPIDrift + TestOpenAPIYAMLValid green.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(rules): author assign_series + assign_counterparty from the admin rule form (#1895)

The rule engine + MCP have supported the assign_series and assign_counterparty
actions for a while, but the admin rule-form editor never offered them — so a
USER could only author membership-defining rules indirectly (agents via MCP).
The rules-as-substrate doctrine says intelligence accrues as rules authored by
users AND agents; this closes that gap.

Adds both as first-class, authorable actions end-to-end:
- rule_form.js: actionTypes entries, parse (JSON->form) and serialize
  (form->JSON), singleton enforcement, and an entity create-by-name affordance.
- rule_form.templ: two action-row UIs modeled on the category dropdown — a
  <select> of existing series/counterparties (value=short_id, label=name) plus
  a mutually-exclusive "create new named…" text input.
- rule_form_types.go + admin/rules.go: thread svc.ListSeries / ListCounterparties
  through RuleFormProps so the dropdowns populate.

Entity selection has two mutually-exclusive modes: pick an existing entity
(serializes as *_short_id) or type a new name (serializes as *_name +
create_if_missing:true). Filling one disables the other, so exactly one
identifier reaches ValidateActions. Round-trip verified: create persists the
exact engine JSON and edit re-populates both modes.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(counterparties): render counterparty assign/unlink in the activity timeline (#1896)

P4 added the counterparty_assigned / counterparty_unlinked annotation kinds
and service.AssignCounterparty/UnlinkCounterparty WRITE them, but the activity
timeline only handled the series_* twins — so counterparty events were dropped
as unknown kinds. Close the gap by mirroring series_assigned exactly.

- service/annotations_enrich.go: enrich counterparty_assigned/_unlinked into a
  Summary sentence (formatCounterpartyMembershipSummary), mirroring
  formatSeriesMembershipSummary. Resolves counterparty_name from the payload
  (same shape the service writes).
- admin/transactions.go: map both kinds to a single "counterparty" ActivityEntry
  type, passing the enriched Summary through — exact mirror of the "series" case.
- templates/.../transaction_detail.templ: add the "counterparty" type to the
  icon switch (store, info tint) and the Summary-fallback sentence branch.
- mcp/tools_tags.go: add "counterparty" → {counterparty_assigned,
  counterparty_unlinked} to mcpAnnotationKinds + the kinds schema/error text.
- docs/activity-timeline.md: document the new Type rows (series + counterparty)
  per the "adding a new system-event kind" contract.

Tests: unit (enrichment summaries for both series + counterparty, actor and
system variants) and integration (assign a counterparty → ListAnnotations
returns the enriched "set the counterparty to {name}" row).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(ui): shared entity components + design-system pass on series/counterparty pages (#1897)

Extract entity-neutral detail-page chrome shared by the recurring-series
and counterparty pages, and bring both surfaces onto the design system.

Shared UI components (entity-neutral):
- Parameterize components.GoverningRulesPanel via GoverningRulesPanelProps
  (Rules + EntityWord + ActionCode) so the empty-state copy is generic;
  both detail pages call it directly. Deletes the counterpartyGoverningRulesPanel
  wrapper.
- Relocate the series-named shared helpers into a new shared file
  (entity_detail_shared.templ / _types.go): seriesPanelHeader -> entityPanelHeader,
  seriesChargeRow -> entityChargeRow, seriesNewRuleBtn -> entityRulesBtn,
  seriesChargeDate -> entityChargeDate. The cross-page dependency is now explicit.

Design-system fixes:
- C-3: replace the hand-rolled seriesEditDrawer slide-over with the shared
  components.Drawer + $store.drawers ('series-edit'); editOpen state dropped
  from the Alpine factory.
- C-6/C-7: extract a counterpartiesList Alpine factory (new
  static/js/admin/components/counterparties.js) handling the search filter and
  shortcuts-scope init/destroy; replaces the inline x-data="{ q: '' }".
- C-1: fold the bare shortcut-scope div on /recurring into the subscriptionsList
  factory's init/destroy.

UI only — service/MCP/REST layers untouched. Behavior identical.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) feat(ui): Workflows-DNA polish — entity avatars + richer rows on series/counterparty pages (#1898)

* feat(ui): Workflows-DNA polish — entity avatars + richer rows on series/counterparty pages

Bring the Workflows-gallery avatar DNA to /counterparties and /recurring so
both surfaces read as one polished product.

New shared components.EntityAvatar — a rounded-square identity tile with three
shapes by precedence: a logo image (counterparty logo_url), a type-tinted lucide
tile (series, reusing the .bb-icon-tile--* tones), or a deterministic OKLCH
gradient monogram (the no-logo fallback; hue is a stable FNV hash of the name).
Pure helpers + unit tests in entity_avatar.go/_test.go; the gradient is driven
by an inline --bb-avatar-hue read by a new .bb-entity-avatar--mono class
(mirrors how .bb-tx-avatar consumes --avatar-color).

Counterparty rows: swap the flat logo tile for EntityAvatar, add a category chip
and a "N charges · M rules" body line. Series rows: type-tinted EntityAvatar
(success/warning/info/neutral so each type reads distinctly on the dark theme),
type chip, and the same charges · rules body line. Detail headers on both pages
fill the EntityHeader icon slot with the same EntityAvatar so the hero matches
the list.

Handlers thread the new data: the /recurring list now wires
ListSeriesGoverningRuleCounts (new service method mirroring the counterparty
one) and the /counterparties list resolves each default-category UUID → display
name for the chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

* fix(ui): build-tag entity_avatar.go !headless && !lite

The new EntityAvatar .go file referenced IconTone/iconToneClass (defined in
the !headless && !lite entity_header_templ.go) but lacked the matching build
constraint, so the headless + lite CI builds failed. Matches the package
convention (user_avatar.go).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore: prune dead code from the P2/P3 deletions (nav badge, unused helper) (#1899)

Pure cleanup, zero behavior change, from the rules-as-universal-substrate
sprint's detector (P2) and category_override/provenance (P3) removals.

- Remove the stale `SeriesCandidates` nav-badge chain: the field on
  NavBadges + its always-0 population in NavBadgesMiddleware, the copy
  into NavProps, the NavProps field, and switch the /recurring nav entry
  from @navLinkBadge (stale detector-era tooltip, count always 0) to a
  plain @NavLink. The badge was never visible.
- Delete the unused `subscriptionSearchHaystack` helper (never called).
- Annotate the three merchant_key back-compat shims (rule_resolver,
  service/types UnmarshalJSON, mcp/tools convertMCPActions) with an
  explicit removal-trigger TODO. These are kept, not removed.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) feat(counterparties): brand logos via logo.dev (hotlink + monogram fallback) (#1900)

* (beyond-roadmap) feat(counterparties): brand logos via logo.dev (hotlink + monogram fallback)

Counterparty avatars now show real brand logos, hotlinked from the free
logo.dev image API, degrading to the gradient monogram (#1898's EntityAvatar)
whenever logo.dev has no logo or the feature is off — never a broken image.

- components.LogoDevURL/LogoDevDomain: derive the registrable host from a
  counterparty website_url (strip scheme + www., reject single-label hosts) and
  build https://img.logo.dev/{domain}?size=128&format=png&retina=true&fallback=404
  [&token=…]. Verified endpoint: a publishable token is required for a real logo
  (401 without), and fallback=404 makes unknown domains degrade to our monogram.
- EntityAvatar gains a LogoDevURL prop: the logo.dev image renders OVER the
  monogram and self-removes on error (onerror), so the tile degrades gracefully.
  Precedence: manual logo_url → logo.dev hotlink → monogram. Series unaffected.
- Service.CounterpartyLogoSettings resolves enable + token with env → app_config
  → default precedence (BREADBOX_COUNTERPARTY_LOGOS / LOGO_DEV_TOKEN env wins).
- Settings → General → Counterparties: auto-save toggle + optional publishable
  token input (public key, stored plaintext).
- Docs: data-model.md app_config keys + a logo.dev hotlinking privacy note
  (domain sent on render; toggle to disable; self-host exempt from link-back).
- Unit tests for domain derivation + URL building; integration test for the
  config precedence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

* fix(counterparties): token-gate logo.dev hotlinking (no token → monogram)

logo.dev's image API requires a publishable key — a tokenless img.logo.dev
request 401s on every render. Make the feature token-gated rather than relying
on a guaranteed-failing request: emit a logo.dev URL only when a token is
configured (plus enabled + derivable domain + no manual logo_url). With no token
(the default) every counterparty renders its gradient monogram and nothing
leaves the browser for logo.dev — a correct, complete default.

- counterpartyLogoDevURL now requires token != "".
- Settings copy: token input is the load-bearing control ("required for logos to
  appear. Get a free token at logo.dev").
- Admin unit test for the gate (token present/absent, toggle off, manual
  override, no/bad domain); docs note updated.

Resolution chain: explicit logo_url → (enabled + token + domain) img.logo.dev →
gradient monogram.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) fix(rules): legible action icon + summary for all action types (#1901)

The /rules list and rule-detail page only summarized a handful of action
types meaningfully (set_category / add_tag / remove_tag / add_comment /
assign_series). The action types added this sprint — assign_counterparty,
flag, unflag, set_metadata, remove_metadata — fell through to a generic
"layers" icon (list) / "Unknown action type" (detail), inconsistent with
the others.

Display-only fix (no engine/semantics change):

- rulesActionIcon (rules.templ): assign_counterparty->store, flag->flag,
  unflag->flag-off, set_metadata/remove_metadata->braces. Icons match the
  ones used elsewhere (Counterparties nav, rule form flag/unflag).
- service.ActionsSummary: add set_metadata ("Set metadata: {key}") and
  remove_metadata ("Remove metadata: {key}"). assign_counterparty / flag /
  unflag were already covered.
- rule_detail.templ "Then" card: full per-action rows (icon tile + label +
  caption) for assign_series, assign_counterparty, flag, unflag,
  set_metadata, remove_metadata instead of the "Unknown action type" fallback.
- rules_types.go PrimaryActionType doc comment updated for accuracy.
- TestActionsSummary extended to cover every action type.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) fix(feed): legible series/counterparty membership events in the activity feed (#1902)

Series/counterparty membership annotations (series_assigned,
series_unlinked, counterparty_assigned, counterparty_unlinked) reach the
home feed (the query only excludes sync_started/sync_updated) and #1896
enriched their per-tx Summary, but the feed's bulk-action rendering had
no cases for them — they fell through to the generic "updated" verb and
the kind-breakdown loop printed the raw kind string ("6 series_assigned").

Add the four membership kinds wherever the feed switches on annotation
kind, mirroring the existing category_set / tag / rule handling:

- feedBulkVerb: "assigned to a series" / "removed from a series" /
  "assigned to a counterparty" / "removed from a counterparty".
- feedBulkKindBreakdown: ordered friendly labels (series-assigned,
  series-removed, counterparty-assigned, counterparty-removed) so mixed
  buckets never leak the raw kind string.
- feedBulkSubjectLabel: single-subject phrasing ("to the Netflix series",
  "to Amazon").
- bulkSubjectKey / bulkSubjectSlug (service): key membership subjects on
  the payload short_id (series:<id> / counterparty:<id>) so same-subject
  rows dedup + group, matching how category_set keys on its slug.
- projectFeedBulkAction (admin): explicit cases stamping the canonical app
  icon (repeat for series, store for counterparty); the human name flows
  from the enriched Annotation.Subject.

Display-only: no engine, schema, or annotation-emission changes.

Tests: feedBulkVerb / feedBulkSubjectLabel / feedBulkKindBreakdown
(pages) and bulkSubjectKey / bulkSubjectSlug (service) cover all four
membership kinds plus a missing-payload guard and category_set
regression.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) fix(counterparties): make logo.dev logo source discoverable on the enrichment form (#1903)

PR #1900 shipped counterparty brand logos via logo.dev, but the enrichment
form's Details card never explained the resolution chain, so users couldn't
discover how to get a logo. Display/copy-only — no engine, data, or config
changes.

- Website: add a hint that its domain auto-fetches a brand logo via logo.dev
  when counterparty logos are enabled and a logo.dev token is set in Settings
  (no token -> no logo; doesn't overclaim).
- Logo URL: reword the hint to convey it's the manual OVERRIDE that takes
  precedence over the auto-fetched logo.

The /counterparties/new create form only has a Name field, so it's untouched.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rules): align admin rule-form field options with engine field names (#1904)

The visual rule builder offered Name / Merchant / Category (raw primary) /
Category (raw detail) as condition fields, but emitted the un-prefixed keys
`name`, `merchant_name`, `category_primary`, `category_detailed`. The rule
engine's validConditionFields map (and the resolver, matcher, and detail-page
label map) only know the canonical `provider_*` names, so submitting any rule
that matched on one of these fields failed with
`invalid parameter: unknown field "merchant_name"`.

This silently broke the doctrine's core path: a merchant-contains →
assign_counterparty / assign_series rule — exactly what the rules-as-substrate
sprint is built around — could not be authored from the admin UI at all.

Rename the four option values (rule_form.templ) and the matching fieldTypes
keys (rule_form.js) to the canonical provider_* names. No engine change: the
backend already expected these. Verified end-to-end via the live UI — author a
Merchant-contains-AMAZON → assign_counterparty rule, save, retroactive apply,
counterparty membership + governing rule + timeline + feed all populate. Edit
mode round-trips correctly (an existing rule's provider_merchant_name condition
now matches the "Merchant" option instead of rendering blank).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) fix(rules): surface retroactive Apply for membership/metadata rules (#1905)

The rule detail page gated its retroactive "Apply now" affordance and
explainer copy on ruleHasRetroactiveAction, which only returned true for
set_category/add_tag/remove_tag. But Service.ApplyRuleRetroactively
materializes a wider set: assign_series, assign_counterparty, set_metadata,
remove_metadata, flag, and unflag also back-fill already-synced rows. Only
add_comment is sync-only.

As a result a rule whose action was e.g. assign_counterparty was wrongly
shown as "only adds comments — no retroactive preview available" with the
Apply button hidden, even though the engine would back-fill it. This blocked
the doctrine's "improve the enrichment layer on already-synced data via the
UI" path.

Display/gating-only — no engine change. The Apply button still POSTs to the
existing /-/rules/{id}/apply handler, which already handles every type.

- Align ruleHasRetroactiveAction with the engine's actual materializing set
  (the 9 non-comment action types) and rewrite its doc comment to name
  ApplyRuleRetroactively as the source of truth.
- Update the apply-modal action list + the preview-card doc comment to
  describe what now applies retroactively (assign_series links charges to
  the series, assign_counterparty binds them to the counterparty, metadata,
  flag/unflag), keeping the add_comment line accurate.
- Add a unit test asserting the gate matches the engine set exactly and
  still excludes the comment-only / empty cases.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
canalesb93 added a commit that referenced this pull request Jun 24, 2026
Resolves conflicts from main's rules-as-substrate merge (#1906) plus
#1871/#1874/#1876 against this PR (retire MCP resources, reads-as-tools,
get_reference for docs, write-tool consolidation).

Resolution decisions:
- Series subsystem: adopt main's model wholesale. #1906 removed
  rekey_series / split_series / review_series / explain_series_candidates
  (handlers + service methods gone) and kept add_series_tag /
  remove_series_tag as separate tools with a name/type-only update_series.
  Backed out this PR's series-tag fold into update_series accordingly and
  updated .claude/rules/mcp.md to match.
- create_transaction_rule: keep this PR's `rules` array shape (folds
  batch_create_rules) but carry main's enriched per-rule DSL
  (metadata/series/flag actions, approx/between ops, date-part fields).
  Updated the reviewer response-shape integration test to read the batch
  envelope.
- MCP resources stay retired: dropped main's re-added registerResources
  (incl. the new counterparty resource template) and the resources.go
  modifications; everything remains a tool.
- Dead prompt files deleted by #1871 (strategy-anomaly-detection /
  -initial-setup / -quick-review) accepted as deletions.
- Docs/prompts: take main for the series model + rules-substrate
  semantics (category_id direct, no category_override); keep this PR's
  tool surface (get_reference, create_transaction_rule rules array).
  Dropped a stray </content> artifact from prompts/mcp/rule-dsl.md.

Verified: go build ./..., go vet ./..., go test ./... (unit), and
integration for internal/mcp + internal/service + internal/api all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7qg4TN9ZPcHsXR714UrjG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stacked Part of a Graphite stacked PR (filter-friendly)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant